理解sychronized锁机制

synchronized是java语言中用来保证线程同步的关键字,它的机制实际上是一种互斥锁,即通过Synchronized修饰的方法或者代码块,它可以保证同一时刻只有一个线程进入临界区访问保证其中的共享数据在,同时,它也可以保证共享数据内存的可见性,即一个线程对于共享数据的修改另外一个线程可以读到该修改的值(可能你会觉得这个理所当然,但事实上,由于各种如CPU或者编译器的指令重排,cache的回写等原因,当多个线程在操作共享数据时往往会有线程安全的问题,一个线程对于共享数据的修改可能另一个线程并不能够立马看到,这个是由于JMM并没有按照happen-before的规则去设计)。

synchronized使用方式

synchronized使我们在java中最常用的一种用来进行线程同步的方式,一般就三种最常见的使用方式:

  1. 修饰实例方法,锁是当前实例对象
  2. 修改静态方法,锁是当前class对象
  3. 修饰同步代码块,锁是指定的对象

如以下代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test{

//修饰实例方法
public synchronized void fun1(){

}
//修饰静态方法
public synchronized static void fun2(){

}
//修饰同步代码块
public void fun3(){
synchronized(this){

}
}
}

底层语义

任何对象在JVM内存中都由三部分构成:对象头、实例数据和对齐填充,而对象头是java实现synchronized的基础,它使用的锁对象信息是存储在java对象头里的。

Java对象头

Java对象头包括两个部分,Mark Word和类型指针,其中Mark Word里存储了对象的hashCode,分代年龄,锁标志位,线程持有的锁,偏向线程ID等信息。类型指针用于确定这个对象是属于哪个类的实例

如32位JVM 中Mark Word默认存储结构为:
32位JVM中的Mark Word结构

对象头除了默认的存储结构,还有如下可能的变化结构,它的存储结构并不是被设计成固定的。
32位JVM中的Mark Word变体结构
从Mark word的存储结构可以看出,它有四种锁状态,分别是:无锁,偏向锁,轻量级锁和重量级锁,其中偏向锁和轻量级锁是在Java SE1.6中针对sychronized做的锁优化策略。关于锁优化的部分我们后面再介绍,这里我们主要分析重量级锁。重量级锁的标记位10,指向互斥量的指针这个其实是指向monitor对象(也称为管程或监视器锁),每个对象实际都和一个monitor对象关联,这也是每个对象之所以能够成为对象锁的原因。使用Synchronized进行同步,本质上就是对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。monitor对象的内部实现中有两个队列,分别位_WaitSet和_EntryList,当多个线程同时访问同步代码时,首先会进入_EntryList队列中,当线程获取到对象的monitor后,monitor对象记录下当前拥有者线程并将引用计数加1;当线程调用wait方法时,将释放当前持有的monitor,引用计数减1,同时该线程进入_waitSet队列中等待被唤醒。

synchronized 的实现原理

将上面的示例代码Test编译生成class文件后,通过javap -v Test查看class文件的字节码内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
Classfile /F:/eclipse/demo/bin/demo/Test.class
Last modified 2019-2-20; size 437 bytes
MD5 checksum 55416227e14946b1fe0b5e1318122ea4
Compiled from "Test.java"
public class demo.Test
minor version: 0
major version: 51
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Class #2 // demo/Test
#2 = Utf8 demo/Test
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Methodref #3.#9 // java/lang/Object."<init>":()V
#9 = NameAndType #5:#6 // "<init>":()V
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Ldemo/Test;
#14 = Utf8 fun1
#15 = Utf8 fun2
#16 = Utf8 fun3
#17 = Utf8 SourceFile
#18 = Utf8 Test.java
{
public demo.Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #8 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 10: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ldemo/Test;

public synchronized void fun1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 14: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this Ldemo/Test;

public static synchronized void fun2();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 18: 0
LocalVariableTable:
Start Length Slot Name Signature

public void fun3();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: dup
2: monitorenter
3: monitorexit
4: return
LineNumberTable:
line 21: 0
line 24: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ldemo/Test;
}

可以看到对于sychronized在同步块中使用monitorenter,monitorexit两个指令来保证互斥性,其中monitorenter指向同步代码块的开始位置,而monitorexit指向同步代码块的结束位置,当执行monitorenter指令时当前线程会去获取对象的monitor,如果monitor的引用计数为0,那么就可以获取到monitor并将计数值加1,此时可以成功进入同步代码块,此时若有其他线程尝试获取monitor该线程会阻塞,而如果当前拥有monitor的线程再次尝试拥有monitor,那么此时也是可以获取到的,这是因为monitor是可重入的,这时候只要对计数值再加1即可。当通过monitorexit退出时,将计数值减1,如果此时计数值为0将释放monitor,这样其他线程就有机会获取到monitor。

而修饰同步方法则采用方法修饰符ACC_SYCHRONIZED来实现互斥性。它的实现是透明的,在sychronized修饰的同步方法被调用和放回操作时,JVM可以根据方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。如果方法的访问标志设置了ACC_SYCHRONIZED,执行线程将尝试先持有monitor,然后再执行方法体,最后在方法返回时释放monitor,同样的,如果一个线程持有了monitor,此时其他线程再次尝试持有monitor进入方法体时将会等待。需要注意的是,如果执行的同步方法抛出了异常,那么这个同步方法持有的monitor将在异常抛出的地方进行释放。

sychronized锁优化

Java SE 1.6对sychronized锁进行了优化,添加了偏向锁和轻量级锁,就如我们之前在对象头的结构中看到的。接下来简单介绍下JVM是如何对sychronized锁进行优化的。

偏向锁

偏向锁是基于这样的事实,大多数情况下,锁并不存在多线程的竞争,而是由同一个线程多次获得,此时为了降低获取锁时的开销而引入了偏向锁的概念。

偏向锁的概念是当一个线程获得锁,那么就进入偏向锁的模式,此时Mark word存储的是偏向锁结构,会记录下该线程的ID,当这个线程再次请求锁的时候,只需要简单测试下对象头Mark word是否存储着指向该线程的偏向锁,无需再做任何同步操作就可以再次获取到锁,在没有锁竞争的情况下,这个由较好的优化效果,因为同一线程是可能多次连续申请锁的。当出现多个线程的锁竞争时,每次申请锁的线程可能都是不同的,偏向锁会失效,此时偏向锁会升级为轻量级锁。

轻量级锁

轻量级锁基于这样的事实,对大多数的锁来说,在同步周期内都没有锁竞争,也就是在线程交替执行同步代码块的场景下,轻量级锁能够发挥其优化的作用。

当一个线程进入同步块的时候,如果同步对象锁状态为无锁状态,那么JVM会在该线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象的Mark word拷贝,称之为Displaced Mark Word,拷贝完成后,JVM会尝试使用CAS操作将对象头中Mark word的指针指向该记录,并将该记录的owner设置为锁对象的Mark word,如果更新成功,这个线程就有了该对象的锁,并将对象Mark word的锁标记位设置位00,表示对象处于轻量级锁状态,如果CAS更新失败了,JVM会检查对象的Mark word是否指向线程当前栈帧中的锁记录,如果是,那么说明当前线程已经拥有了这个对象的锁,可以直接进入到同步中执行。否则说明多个线程竞争锁,轻量级锁就膨胀为重量级锁。

坚持原创技术分享,您的支持将鼓励我继续创作!